// Inspector plugin.
// -----------------

// This plugin creates a two-way data-binding between the cell model and a generated
// HTML form with input fields of a type declaratively specified in an options object passed
// into the element inspector.

/*
USAGE:

var inspector = new joint.ui.Inspector({
    cellView: cellView,
    inputs: {
            attrs: {
                text: {
                    'font-size': { type: 'number', min: 5, max: 80, group: 'text', index: 2 },
                    'text': { type: 'textarea', group: 'text', index: 1 }
                }
            },
            position: {
                x: { type: 'number', group: 'geometry', index: 1 },
                y: { type: 'number', group: 'geometry', index: 2 }
            },
            size: {
                width: { type: 'number', min: 1, max: 500, group: 'geometry', index: 3 },
                height: { type: 'number', min: 1, max: 500, group: 'geometry', index: 4 }
            },
            mydata: {
                foo: { type: 'textarea', group: 'data' }
            }
   },
   groups: {
           text: { label: 'Text', index: 1 },
           geometry: { label: 'Geometry', index: 2 },
           data: { label: 'data', index: 3 }
   }
});

$('.inspector-container').append(inspector.render().el);
*/

joint.ui.Inspector = Backbone.View.extend({

    className: 'inspector',

    events: {
        'mousedown': 'startBatchCommand',
        'change': 'onChangeInput',
        'click .group-label': 'toggleGroup',
        'click .btn-list-add': 'addListItem',
        'click .btn-list-del': 'deleteListItem'
    },

    initialize: function() {

        this.options.groups = this.options.groups || {};

        _.bindAll(this, 'stopBatchCommand');

        // Start a batch command on `mousedown` over the inspector and stop it when the mouse is
        // released anywhere in the document. This prevents setting attributes in tiny steps
        // when e.g. a value is being adjusted through a slider. This gives other parts
        // of the application a chance to treat the serveral little changes as one change.
        // Consider e.g. the CommandManager plugin.
        $(document).on('mouseup', this.stopBatchCommand);
        

        // Flatten the `inputs` object until the level where the options object is.
        // This produces an object with this structure: { <path>: <options> }, e.g. { 'attrs/rect/fill': { type: 'color' } }

        this.flatAttributes = joint.util.flattenObject(this.options.inputs, '/', function(obj) {
            // Stop flattening when we reach an object that contains the `type` property. We assume
            // that this is our options object. @TODO This is not very robust as there could
            // possibly be another object with a property `type`. Instead, we should stop
            // just before the nested leaf object.
            return obj.type;
        });

        // Add the attributes path the options object as we're converting the flat object to an array
        // and so we would loose the keys otherwise.
        var attributesArray = _.map(this.flatAttributes, function(options, path) {
            
            options.path = path;
            return options;
        });

        // Sort the flat attributes object by two criteria: group first, then index inside that group.
        this.groupedFlatAttributes = _.sortBy(attributesArray, function(options, path) {

            var groupOptions = this.options.groups[options.group];
            return groupOptions ? 'a' + groupOptions.index + 'b' + options.index : Number.MAX_VALUE;
                
        }, this);

        // Listen on events on the cell.
        this.listenTo(this.options.cellView.model, 'all', this.onCellChange, this);
    },

    onCellChange: function(eventName, cell, change, opt) {

        opt = opt || {};

        // Do not react on changes that happened inside this inspector. This would
        // cause a re-render of the same inspector triggered by an input change in this inspector.
        if (opt['inspector_' + this.cid]) return;

        // Note that special care is taken for all the transformation attribute changes
        // (`position`, `size` and `angle`). See below for details.
        
        switch (eventName) {

          case 'remove':
            // Make sure the element inspector gets removed when the cell is removed from the graph.
            // Otherwise, a zombie cell could possibly be updated.
            this.remove()
            break;
          case 'change:position':
            // Make a special case for `position` as this one is performance critical.
            // There is no need to rerender the whole inspector but only update the position input.
            this.updateInputPosition();
            break;
          case 'change:size':
            // Make a special case also for the `size` attribute for the same reasons as for `position`.
            this.updateInputSize();
            break;
          case 'change:angle':
            // Make a special case also for the `angle` attribute for the same reasons as for `position`.
            this.updateInputAngle();
            break;
          case 'change:source':
          case 'change:target':
            // Make a special case also for the 'source' and 'target' of a link for the same reasons
            // as for 'position'. We don't expect source or target to be configurable.
            // That's why we do nothing here.
        default:
            // Re-render only on specific attributes changes. These are all events that starts with `'change:'`.
            // Otherwise, the re-render would be called unnecessarily (consider generic `'change'` event, `'bach:start'`, ...).
            var changeAttributeEvent = 'change:';
            if (eventName.slice(0, changeAttributeEvent.length) === changeAttributeEvent) {
                
                this.render();
            }
            break;
        }
    },

    render: function() {

        // Cache all the inputs so that we have a quick access to them based on the attribute path.
        this._byPath = {};
        
        this.$el.empty();

        var lastGroup;
        var $groups = [];
        var $group;
        
        _.each(this.groupedFlatAttributes, function(options) {

            if (lastGroup !== options.group) {
                // A new group should be created.

                var groupOptions = this.options.groups[options.group];
                var groupLabel = groupOptions ? groupOptions.label || options.group : options.group;
                
                $group = $(joint.templates.inspector['group.html']({ label: groupLabel }));
                $groups.push($group);
            }
            
            this.renderTemplate($group, options, options.path);

            lastGroup = options.group;
            
        }, this);

        this.$el.append($groups);

        this.trigger('render');
        
        return this;
    },

    // Get the value of the attribute at `path` based on the `options.defaultValue`,
    // and `options.valueRegExp` if present.
    getCellAttributeValue: function(path, options) {

        var cell = this.options.cellView.model;

        var value = joint.util.getByPath(cell.attributes, path, '/');
        if (_.isUndefined(value) && !_.isUndefined(options.defaultValue)) {
            value = options.defaultValue;
        }

        if (options.valueRegExp) {

            if (_.isUndefined(value)) {
                
                throw new Error('Inspector: defaultValue must be present when valueRegExp is used.');
            }
            
            var valueMatch = value.match(new RegExp(options.valueRegExp));
            value = valueMatch && valueMatch[2];
        }

        return value;
    },
    
    renderTemplate: function($el, options, path) {

        $el = $el || this.$el;

        var value = this.getCellAttributeValue(path, options);
        
        var inputHtml = joint.templates.inspector[options.type + '.html']({
            options: options,
            type: options.type,
            label: options.label || path,
            attribute: path,
            value: value
        });
        var $input = $(inputHtml);

        // `options.attrs` allows for setting arbitrary attributes on the generated HTML.
        // This object is of the form: `<selector> : { <attributeName> : <attributeValue>, ... }`
        _.each(options.attrs, function(attrs, selector) {
            $input.find(selector).addBack().filter(selector).attr(attrs);
        });

        if (options.type === 'list') {
            
            _.each(value, function(itemValue, idx) {

                var $listItem = $(joint.templates.inspector['list-item.html']({
                    index: idx
                }));

                this.renderTemplate($listItem, options.item, path + '/' + idx);

                $input.find('.list-items').append($listItem);
                
            }, this);
            
        } else if (options.type === 'object') {

            options.flatAttributes = joint.util.flattenObject(options.properties, '/', function(obj) {
                // Stop flattening when we reach an object that contains the `type` property. We assume
                // that this is our options object. @TODO This is not very robust as there could
                // possibly be another object with a property `type`. Instead, we should stop
                // just before the nested leaf object.
                return obj.type;
            });

            var attributesArray = _.map(options.flatAttributes, function(options, path) {
                options.path = path;
                return options;
            });
            // Sort the attributes by `index` and assign the `path` to the `options` object
            // so that we can acess it later.
            attributesArray = _.sortBy(attributesArray, function(options) {

                return options.index;
            });
            
            _.each(attributesArray, function(propertyOptions) {

                var $objectProperty = $(joint.templates.inspector['object-property.html']({
                    property: propertyOptions.path
                }));

                this.renderTemplate($objectProperty, propertyOptions, path + '/' + propertyOptions.path);

                $input.find('.object-properties').append($objectProperty);
                
            }, this);
        }

        this._byPath[path] = $input;

        $el.append($input);
    },

    toggleGroup: function(evt) {

        $(evt.target).closest('.group').toggleClass('closed');
    },

    updateInputPosition: function() {

        var $inputX = this._byPath['position/x'];
        var $inputY = this._byPath['position/y'];

        var position = this.options.cellView.model.get('position');
        
        if ($inputX) { $inputX.val(position.x); }
        if ($inputY) { $inputY.val(position.y); }
    },
    updateInputSize: function() {

        var $inputWidth = this._byPath['size/width'];
        var $inputHeight = this._byPath['size/height'];

        var size = this.options.cellView.model.get('size');
        
        if ($inputWidth) { $inputWidth.val(size.width); }
        if ($inputHeight) { $inputHeight.val(size.height); }
    },
    updateInputAngle: function() {

        var $inputAngle = this._byPath['angle'];

        var angle = this.options.cellView.model.get('angle');
        
        if ($inputAngle) { $inputAngle.val(angle); }
    },

    onChangeInput: function(evt) {

        this.updateCell(evt.target);
    },

    updateCell: function(input) {

        var cell = this.options.cellView.model;

        var $input = $(input);
        var $attribute = $input.closest('[data-attribute]');
        var path = $attribute.data('attribute');
        var type = $attribute.data('type');

        var value = this.parse(type, $input.val(), $input[0]);
        var options = this.flatAttributes[path];
        var kind = 'primitive'; // or 'array' or 'object'

        if (!options) {
            // This can happen for list and object types.
            var $parentAttribute = $input.parents('[data-attribute]').first();
            var parentPath = $parentAttribute.data('attribute');
            options = this.flatAttributes[parentPath];

            if (!options) {
                // This can happen for an object type inside a list.
                var $parentParentAttribute = $input.parents('[data-attribute]').last();
                var parentParentPath = $parentParentAttribute.data('attribute');
                var listItemOptions = this.flatAttributes[parentParentPath].item;
                var nestedPropertyPath = $input.closest('.object-property').attr('data-property');
                options = listItemOptions.flatAttributes[nestedPropertyPath];
                // We're inside a list, therefore, we need to treat the whole thing as an `array` kind.
                kind = 'array';
            }
            
            // assert(options)
            if (options.type === 'list') {
                kind = 'array';
                options = options.item;
            }
        }
        
        if (options.valueRegExp) {
            var oldValue = joint.util.getByPath(cell.attributes, path, '/') || options.defaultValue;
            value = oldValue.replace(new RegExp(options.valueRegExp), '$1' + value + '$3');
        }

        var pathArray = path.split('/');
        var property = pathArray[0];
        var currentValue = cell.get(property);
        // Make sure the inspector does not rerender itself after the `set()` call below. It is
        // not necessary as the input already contains the right value.
        var opt = {};
        opt['inspector_' + this.cid] = true;
        
        if (pathArray.length > 1) {
            // Nested object.

            var newValue = joint.util.setByPath({}, path, value, '/');
            // The `extend()` makes sure that even if the `currentValue` is an array, it is converted
            // to object. This makes it easy to merge arrays.
            var currentObjectValue = _.extend({}, currentValue || {});
            newValue = _.merge({}, currentObjectValue, newValue[property]);
            // If we're dealing with an array (`type === 'list'`), we need to convert the
            // object back to an array.
            newValue = (kind === 'array' ? _.toArray(newValue) : newValue);
            // Finally, set the property on the cell.
            cell.set(property, newValue, opt);
                
        } else {
            // Primitive object.

            cell.set(property, value, opt);
        }
    },

    // Parse the input `value` based on the input `type`.
    // Override this method if you need your own specific parsing.
    parse: function(type, value, targetElement) {
        
        switch (type) {
          case 'number':
            value = parseFloat(value);
            break;
          case 'toggle':
            value = targetElement.checked;
            break;
          default:
            value = value;
            break;
        }
        return value;
    },

    startBatchCommand: function() {

        this.options.cellView.model.trigger('batch:start');
    },
    
    stopBatchCommand: function() {

        this.options.cellView.model.trigger('batch:stop');
    },

    addListItem: function(evt) {

        var $target = $(evt.target);
        var $attribute = $target.closest('[data-attribute]');
        var path = $attribute.data('attribute');
        var options = this.flatAttributes[path];

        var currValue = joint.util.getByPath(this.options.cellView.model.attributes, path, '/');
        var index = currValue ? currValue.length : 0;

        var $listItem = $(joint.templates.inspector['list-item.html']({ index: index }));
        
        this.renderTemplate($listItem, options.item, path + '/' + index);

        $target.parent().find('.list-items').append($listItem);
        $listItem.find('input').focus();

        _.each($listItem.find('input'), this.updateCell, this);

        this.trigger('render');
    },
    
    deleteListItem: function(evt) {

        var $listItem = $(evt.target).closest('.list-item');
        var $input = $listItem.find('input');
        var $attribute = $input.closest('[data-attribute]');
        var path = $attribute.attr('data-attribute');

        var cell = this.options.cellView.model;

        // Remove the list item by index that can be retrieved from the `data-attribute`.
        // Index is the last part of the `pathArray`, the first part is the top level property name.
        var pathArray = path.split('/');
        var property = pathArray[0];
        
        var currentValue = _.cloneDeep(cell.get(property));
        var index = parseInt($listItem.attr('data-index'), 10);

        currentValue.splice(index, 1);
        cell.set(property, currentValue);
    },

    remove: function() {

        $(document).off('mouseup', this.stopBatchCommand);
        return Backbone.View.prototype.remove.apply(this, arguments);
    }
});
